123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767 |
- <template>
- <div v-if="workflow" class="flex h-screen">
- <div
- v-if="state.showSidebar"
- class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
- >
- <workflow-edit-block
- v-if="editState.editing"
- v-model:autocomplete="autocompleteState.cache"
- :data="editState.blockData"
- :data-changed="autocompleteState.dataChanged"
- :workflow="workflow"
- :editor="editor"
- @update="updateBlockData"
- @close="(editState.editing = false), (editState.blockData = {})"
- />
- <workflow-details-card
- v-else
- :workflow="workflow"
- @update="updateWorkflow"
- />
- </div>
- <div class="flex-1 relative overflow-auto">
- <div
- class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
- >
- <ui-tabs
- v-model="state.activeTab"
- class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
- >
- <button
- v-tooltip="
- `${t('workflow.toggleSidebar')} (${
- shortcut['editor:toggle-sidebar'].readable
- })`
- "
- style="margin-right: 6px"
- @click="toggleSidebar"
- >
- <v-remixicon
- :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
- />
- </button>
- <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
- <ui-tab value="logs" class="flex items-center">
- {{ t('common.log', 2) }}
- <span
- v-if="workflowStates.length > 0"
- class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
- style="min-width: 25px"
- >
- {{ workflowStates.length }}
- </span>
- </ui-tab>
- </ui-tabs>
- <div class="flex-grow pointer-events-none" />
- <editor-local-actions
- :editor="editor"
- :workflow="workflow"
- :is-data-changed="state.dataChanged"
- @update="onActionUpdated"
- @modal="(modalState.name = $event), (modalState.show = true)"
- />
- </div>
- <ui-tab-panels
- v-model="state.activeTab"
- class="overflow-hidden h-full w-full"
- @drop="onDropInEditor"
- @dragend="clearHighlightedElements"
- @dragover.prevent="onDragoverEditor"
- >
- <ui-tab-panel cache value="editor" class="w-full">
- <workflow-editor
- v-if="state.workflowConverted"
- :id="route.params.id"
- :data="workflow.drawflow"
- :class="{ 'animate-blocks': state.animateBlocks }"
- class="h-screen"
- @init="onEditorInit"
- @edit="initEditBlock"
- @update:node="state.dataChanged = true"
- @delete:node="state.dataChanged = true"
- >
- <template #controls-append>
- <button
- v-tooltip="t('workflow.autoAlign.title')"
- class="control-button hoverable ml-2"
- @click="autoAlign"
- >
- <v-remixicon name="riMagicLine" />
- </button>
- </template>
- </workflow-editor>
- <editor-local-ctx-menu
- v-if="editor"
- :editor="editor"
- @copy="copySelectedElements"
- @paste="pasteCopiedElements"
- @duplicate="duplicateElements"
- />
- </ui-tab-panel>
- <ui-tab-panel value="logs" class="mt-24 container">
- <editor-logs
- :workflow-id="route.params.id"
- :workflow-states="workflowStates"
- />
- </ui-tab-panel>
- </ui-tab-panels>
- </div>
- </div>
- <ui-modal
- v-model="modalState.show"
- :content-class="activeWorkflowModal?.width || 'max-w-xl'"
- v-bind="activeWorkflowModal.attrs || {}"
- >
- <template v-if="activeWorkflowModal.title" #header>
- {{ activeWorkflowModal.title }}
- <a
- v-if="activeWorkflowModal.docs"
- :title="t('common.docs')"
- :href="activeWorkflowModal.docs"
- target="_blank"
- class="inline-block align-middle"
- >
- <v-remixicon name="riInformationLine" size="20" />
- </a>
- </template>
- <component
- :is="activeWorkflowModal.component"
- v-bind="{ workflow }"
- v-on="activeWorkflowModal?.events || {}"
- @update="updateWorkflow"
- @close="modalState.show = false"
- />
- </ui-modal>
- </template>
- <script setup>
- import {
- watch,
- provide,
- reactive,
- computed,
- onMounted,
- shallowRef,
- onBeforeUnmount,
- } from 'vue';
- import { useI18n } from 'vue-i18n';
- import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
- import { customAlphabet } from 'nanoid';
- import defu from 'defu';
- import dagre from 'dagre';
- import { useStore } from '@/stores/main';
- import { useUserStore } from '@/stores/user';
- import { useWorkflowStore } from '@/stores/workflow';
- import { useShortcut, getShortcut } from '@/composable/shortcut';
- import { tasks } from '@/utils/shared';
- import { debounce, parseJSON, throttle } from '@/utils/helper';
- import { fetchApi } from '@/utils/api';
- import browser from 'webextension-polyfill';
- import DroppedNode from '@/utils/editor/DroppedNode';
- import convertWorkflowData from '@/utils/convertWorkflowData';
- import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
- import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
- import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
- import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
- import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
- import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
- import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
- import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
- import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
- import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
- const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 7);
- const { t } = useI18n();
- const store = useStore();
- const route = useRoute();
- const router = useRouter();
- const userStore = useUserStore();
- const workflowStore = useWorkflowStore();
- const editor = shallowRef(null);
- const state = reactive({
- showSidebar: true,
- dataChanged: false,
- animateBlocks: false,
- workflowConverted: false,
- activeTab: route.query.tab || 'editor',
- });
- const modalState = reactive({
- name: '',
- show: false,
- });
- const editState = reactive({
- blockData: {},
- editing: false,
- });
- const autocompleteState = reactive({
- cache: new Map(),
- dataChanged: false,
- });
- const workflowPayload = {
- data: {},
- isUpdating: false,
- };
- const workflowModals = {
- table: {
- icon: 'riKey2Line',
- width: 'max-w-2xl',
- component: WorkflowDataTable,
- title: t('workflow.table.title'),
- docs: 'https://docs.automa.site/api-reference/table.html',
- },
- 'workflow-share': {
- icon: 'riShareLine',
- component: WorkflowShare,
- attrs: {
- blur: true,
- persist: true,
- customContent: true,
- },
- events: {
- close() {
- modalState.show = false;
- modalState.name = '';
- },
- publish() {
- modalState.show = false;
- modalState.name = '';
- },
- },
- },
- 'global-data': {
- width: 'max-w-2xl',
- icon: 'riDatabase2Line',
- component: WorkflowGlobalData,
- title: t('common.globalData'),
- docs: 'https://docs.automa.site/api-reference/global-data.html',
- },
- settings: {
- width: 'max-w-2xl',
- icon: 'riSettings3Line',
- component: WorkflowSettings,
- title: t('common.settings'),
- attrs: {
- customContent: true,
- },
- events: {
- close() {
- modalState.show = false;
- modalState.name = '';
- },
- },
- },
- };
- const workflow = computed(() => workflowStore.getById(route.params.id));
- const workflowStates = computed(() =>
- workflowStore.getWorkflowStates(route.params.id)
- );
- const activeWorkflowModal = computed(
- () => workflowModals[modalState.name] || {}
- );
- provide('workflow', {
- editState,
- data: workflow,
- });
- provide('workflow-editor', editor);
- const updateBlockData = debounce((data) => {
- const node = editor.value.getNode.value(editState.blockData.blockId);
- const dataCopy = JSON.parse(JSON.stringify(data));
- if (editState.blockData.itemId) {
- const itemIndex = node.data.blocks.findIndex(
- ({ itemId }) => itemId === editState.blockData.itemId
- );
- if (itemIndex === -1) return;
- node.data.blocks[itemIndex].data = dataCopy;
- } else {
- node.data = dataCopy;
- }
- editState.blockData.data = data;
- state.dataChanged = true;
- }, 250);
- const updateHostedWorkflow = throttle(async () => {
- if (!userStore.user || workflowPayload.isUpdating) return;
- const isHosted = userStore.hostedWorkflows[route.params.id];
- const isBackup = userStore.backupIds.includes(route.params.id);
- const workflowExist = workflowStore.getById(route.params.id);
- if (
- (!isBackup && !isHosted) ||
- (workflowExist && Object.keys(workflowPayload.data).length === 0)
- )
- return;
- workflowPayload.isUpdating = true;
- const delKeys = [
- 'id',
- 'pass',
- 'logs',
- 'trigger',
- 'createdAt',
- 'isDisabled',
- 'isProtected',
- ];
- delKeys.forEach((key) => {
- delete workflowPayload.data[key];
- });
- try {
- if (typeof workflowPayload.data.drawflow === 'string') {
- workflowPayload.data.drawflow = parseJSON(
- workflowPayload.data.drawflow,
- workflowPayload.data.drawflow
- );
- }
- const response = await fetchApi(`/me/workflows/${route.params.id}`, {
- method: 'PUT',
- keepalive: true,
- body: JSON.stringify({
- workflow: workflowPayload.data,
- }),
- });
- if (!response.ok) throw new Error(response.message);
- if (isBackup) {
- const result = await response.json();
- if (result.updatedAt) {
- await browser.storage.local.set({ lastBackup: result.updatedAt });
- }
- }
- workflowPayload.data = {};
- workflowPayload.isUpdating = false;
- } catch (error) {
- console.error(error);
- workflowPayload.isUpdating = false;
- }
- }, 5000);
- const onNodesChange = debounce((changes) => {
- changes.forEach(({ type, id }) => {
- if (type === 'remove') {
- if (editState.blockData.blockId === id) {
- editState.editing = false;
- editState.blockData = {};
- }
- state.dataChanged = true;
- }
- });
- }, 250);
- const onEdgesChange = debounce((changes) => {
- changes.forEach(({ type }) => {
- if (state.dataChanged) return;
- state.dataChanged = type !== 'select';
- });
- }, 250);
- function autoAlign() {
- state.animateBlocks = true;
- const graph = new dagre.graphlib.Graph();
- graph.setGraph({
- rankdir: 'LR',
- ranksep: 100,
- ranker: 'tight-tree',
- });
- graph._isMultigraph = true;
- graph.setDefaultEdgeLabel(() => ({}));
- editor.value.getNodes.value.forEach(({ id, label, dimensions }) => {
- graph.setNode(id, {
- label,
- width: dimensions.width,
- height: dimensions.height,
- });
- });
- editor.value.getEdges.value.forEach(({ source, target, id }) => {
- graph.setEdge(source, target, { id });
- });
- dagre.layout(graph);
- const nodeChanges = graph.nodes().map((nodeId) => {
- const { x, y } = graph.node(nodeId);
- return {
- id: nodeId,
- type: 'position',
- dragging: false,
- position: { x, y },
- };
- });
- editor.value.applyNodeChanges(nodeChanges);
- editor.value.fitView();
- setTimeout(() => {
- state.dataChanged = true;
- state.animateBlocks = false;
- }, 500);
- }
- function toggleSidebar() {
- state.showSidebar = !state.showSidebar;
- localStorage.setItem('workflow:sidebar', state.showSidebar);
- }
- function initEditBlock(data) {
- const { editComponent, data: blockDefData } = tasks[data.id];
- const blockData = defu(data.data, blockDefData);
- editState.blockData = { ...data, editComponent, data: blockData };
- if (data.id === 'wait-connections') {
- const connections = editor.value.getEdges.value.reduce(
- (acc, { target, sourceNode, source }) => {
- if (target !== data.blockId) return acc;
- let name = t(`workflow.blocks.${sourceNode.label}.name`);
- const { description } = sourceNode.data;
- if (description) name += ` (${description})`;
- acc.push({
- name,
- id: source,
- });
- return acc;
- },
- []
- );
- editState.blockData.connections = connections;
- }
- editState.editing = true;
- }
- async function updateWorkflow(data) {
- try {
- await workflowStore.update({
- data,
- id: route.params.id,
- });
- workflowPayload.data = { ...workflowPayload.data, ...data };
- await updateHostedWorkflow();
- } catch (error) {
- console.error(error);
- }
- }
- function onActionUpdated({ data, changedIndicator }) {
- state.dataChanged = changedIndicator;
- workflowPayload.data = { ...workflowPayload.data, ...data };
- updateHostedWorkflow();
- }
- function onEditorInit(instance) {
- editor.value = instance;
- instance.onEdgeDoubleClick(({ edge }) => {
- instance.removeEdges([edge]);
- });
- instance.onEdgesChange(onEdgesChange);
- instance.onNodesChange(onNodesChange);
- instance.removeSelectedNodes(
- instance.getSelectedNodes.value.map(({ id }) => id)
- );
- instance.removeSelectedEdges(
- instance.getSelectedEdges.value.map(({ id }) => id)
- );
- const { blockId } = route.query;
- if (blockId) {
- const block = instance.getNode.value(blockId);
- if (!block) return;
- instance.addSelectedNodes([block]);
- setTimeout(() => {
- const editorContainer = document.querySelector('.vue-flow');
- const { height, width } = editorContainer.getBoundingClientRect();
- const { x, y } = block.position;
- instance.setTransform({
- y: -(y - height / 2),
- x: -(x - width / 2) - 200,
- zoom: 1,
- });
- }, 200);
- }
- }
- function clearHighlightedElements() {
- const elements = document.querySelectorAll(
- '.dropable-area__node, .dropable-area__handle'
- );
- elements.forEach((element) => {
- element.classList.remove('dropable-area__node');
- element.classList.remove('dropable-area__handle');
- });
- }
- function toggleHighlightElement({ target, elClass, classes }) {
- const targetEl = target.closest(elClass);
- if (targetEl) {
- targetEl.classList.add(classes);
- } else {
- const elements = document.querySelectorAll(`.${classes}`);
- elements.forEach((element) => {
- element.classList.remove(classes);
- });
- }
- }
- function onDragoverEditor({ target }) {
- toggleHighlightElement({
- target,
- elClass: '.vue-flow__handle.source',
- classes: 'dropable-area__handle',
- });
- if (!target.closest('.vue-flow__handle')) {
- toggleHighlightElement({
- target,
- elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
- classes: 'dropable-area__node',
- });
- }
- }
- function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
- const block = parseJSON(dataTransfer.getData('block'), null);
- if (!block) return;
- clearHighlightedElements();
- const nodeEl = DroppedNode.isNode(target);
- if (nodeEl) {
- DroppedNode.replaceNode(editor.value, { block, target: nodeEl });
- return;
- }
- const isTriggerExists =
- block.id === 'trigger' &&
- editor.value.getNodes.value.some((node) => node.label === 'trigger');
- if (isTriggerExists) return;
- const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
- const newNode = {
- position,
- id: nanoid(),
- label: block.id,
- data: block.data,
- type: block.component,
- };
- editor.value.addNodes([newNode]);
- const edgeEl = DroppedNode.isEdge(target);
- const handleEl = DroppedNode.isHandle(target);
- if (handleEl) {
- DroppedNode.appendNode(editor.value, {
- target: handleEl,
- nodeId: newNode.id,
- });
- } else if (edgeEl) {
- DroppedNode.insertBetweenNode(editor.value, {
- target: edgeEl,
- nodeId: newNode.id,
- outputs: block.outputs,
- });
- }
- if (block.fromGroup) {
- setTimeout(() => {
- const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
- blockEl?.setAttribute('group-item-id', block.itemId);
- }, 200);
- }
- state.dataChanged = true;
- }
- function copyElements(nodes, edges, initialPos) {
- const newIds = new Map();
- let firstNodePos = null;
- const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
- const newNodeId = nanoid();
- const nodePos = {
- z: position.z || 0,
- y: position.y + 50,
- x: position.x + 50,
- };
- newIds.set(id, newNodeId);
- if (initialPos) {
- if (index === 0) {
- firstNodePos = {
- x: nodePos.x,
- y: nodePos.y,
- };
- initialPos = editor.value.project({
- y: initialPos.clientY,
- x: initialPos.clientX - 360,
- });
- Object.assign(nodePos, initialPos);
- } else {
- const xDistance = nodePos.x - firstNodePos.x;
- const yDistance = nodePos.y - firstNodePos.y;
- nodePos.x = initialPos.x + xDistance;
- nodePos.y = initialPos.y + yDistance;
- }
- }
- return {
- type,
- data,
- label,
- id: newNodeId,
- selected: true,
- position: nodePos,
- };
- });
- const newEdges = edges.reduce(
- (acc, { target, targetHandle, source, sourceHandle }) => {
- const targetId = newIds.get(target);
- const sourceId = newIds.get(source);
- if (!targetId || !sourceId) return acc;
- acc.push({
- selected: true,
- target: targetId,
- source: sourceId,
- id: `edge-${nanoid()}`,
- targetHandle: targetHandle.replace(target, targetId),
- sourceHandle: sourceHandle.replace(source, sourceId),
- });
- return acc;
- },
- []
- );
- return {
- nodes: newNodes,
- edges: newEdges,
- };
- }
- function duplicateElements({ nodes, edges }) {
- const selectedNodes = editor.value.getSelectedNodes.value;
- const selectedEdges = editor.value.getSelectedEdges.value;
- const { edges: newEdges, nodes: newNodes } = copyElements(
- nodes || selectedNodes,
- edges || selectedEdges
- );
- editor.value.removeSelectedNodes(selectedNodes);
- editor.value.removeSelectedEdges(selectedEdges);
- editor.value.addNodes(newNodes);
- editor.value.addEdges(newEdges);
- }
- function copySelectedElements(data = {}) {
- store.copiedEls.nodes = data.nodes || editor.value.getSelectedNodes.value;
- store.copiedEls.edges = data.edges || editor.value.getSelectedEdges.value;
- }
- function pasteCopiedElements(position) {
- editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
- editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
- const { nodes, edges } = copyElements(
- store.copiedEls.nodes,
- store.copiedEls.edges,
- position
- );
- editor.value.addNodes(nodes);
- editor.value.addEdges(edges);
- }
- function onKeydown({ ctrlKey, metaKey, key }) {
- const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
- if (command('c')) {
- copySelectedElements();
- } else if (command('v')) {
- pasteCopiedElements();
- }
- }
- const shortcut = useShortcut([
- getShortcut('editor:toggle-sidebar', toggleSidebar),
- getShortcut('editor:duplicate-block', duplicateElements),
- ]);
- watch(
- () => state.activeTab,
- (value) => {
- router.replace({ ...route, query: { tab: value } });
- }
- );
- /* eslint-disable consistent-return */
- onBeforeRouteLeave(() => {
- updateHostedWorkflow();
- if (!state.dataChanged) return;
- const confirm = window.confirm(t('message.notSaved'));
- if (!confirm) return false;
- });
- onMounted(() => {
- if (!workflow.value) {
- router.replace('/');
- return null;
- }
- state.showSidebar =
- JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
- const convertedData = convertWorkflowData(workflow.value);
- updateWorkflow({ drawflow: convertedData.drawflow }).then(() => {
- state.workflowConverted = true;
- });
- window.onbeforeunload = () => {
- updateHostedWorkflow();
- if (state.dataChanged) {
- return t('message.notSaved');
- }
- };
- window.addEventListener('keydown', onKeydown);
- });
- onBeforeUnmount(() => {
- window.onbeforeunload = null;
- window.removeEventListener('keydown', onKeydown);
- });
- </script>
- <style>
- .vue-flow,
- .editor-tab {
- width: 100%;
- height: 100%;
- }
- .vue-flow__node {
- @apply rounded-lg;
- }
- .dropable-area__node,
- .dropable-area__handle {
- @apply ring-4;
- }
- .animate-blocks {
- .vue-flow__transformationpane,
- .vue-flow__node {
- transition: transform 300ms ease;
- }
- }
- </style>
|